package service

import (
	"context"
	"database/sql"

	"gitee.com/hp-huiw/family-expense-api/internal/dto"
	"gitee.com/hp-huiw/my-go-core/config"
	"gitee.com/hp-huiw/my-go-core/date"
	"gitee.com/hp-huiw/my-go-core/errorcode"
	"gitee.com/hp-huiw/my-go-core/security"
	"gitee.com/hp-huiw/my-go-core/util"
)

// region: Public members

type AuthenticationService interface {
	AuthenticateByCredential(ctx context.Context, username string, password string, ipAddress string, deviceInfo *dto.UserDeviceInfoRequest, systemInfo *dto.UserSystemInfoRequest) (myUserDetails *security.MyUserDetails, errorCode string)
	AuthenticateByAuthCode(ctx context.Context, username string, authCode string) (myUserDetails *security.MyUserDetails, errorCode string)
	RefreshToken(ctx context.Context, refreshToken string, ipAddress string, deviceInfo *dto.UserDeviceInfoRequest, systemInfo *dto.UserSystemInfoRequest) (myUserDetails *security.MyUserDetails, errorCode string)
	SwitchAccountGroup(ctx context.Context, accountGroupID string, userID string, username string, ipAddress string, deviceInfo *dto.UserDeviceInfoRequest, systemInfo *dto.UserSystemInfoRequest) (myUserDetails *security.MyUserDetails, errorCode string)
	Logout(ctx context.Context, token string)
}

// NewAuthenticationService returns a new AuthenticationService.
func NewAuthenticationService(db *sql.DB) AuthenticationService {
	return &authenticationService{
		db:                     db,
		userService:            NewUserService(db),
		userActiveTokenService: NewUserActiveTokenService(db),
	}
}

// AuthenticateByCredential authenticates user based on username and password.
func (s *authenticationService) AuthenticateByCredential(ctx context.Context, username string, password string, ipAddress string, deviceInfo *dto.UserDeviceInfoRequest, systemInfo *dto.UserSystemInfoRequest) (myUserDetails *security.MyUserDetails, errorCode string) {
	myUserDetails = &security.MyUserDetails{}
	errorCode = ""
	validatePassword, err := s.userService.ValidatePassword(ctx, username, password)

	if err != nil {
		errorCode = errorcode.ErrorUserAccountBadCredential
	}

	if validatePassword {
		user, err := s.userService.QueryStatus(ctx, username)

		if err != nil {
			errorCode = errorcode.ErrorServiceUnexpectedError
		}

		// Checks on user each status.
		if !*user.Enabled {
			errorCode = errorcode.ErrorUserAccountDisabled
		} else if user.AccountExpired {
			errorCode = errorcode.ErrorUserAccountExpired
		} else if user.AccountLocked {
			errorCode = errorcode.ErrorUserAccountLocked
		} else if user.PasswordExpired {
			errorCode = errorcode.ErrorUserPasswordExpired
		} else {
			if myUserDetails, err = s.generateUserDetails(ctx, username, deviceInfo.ID); err != nil {
				return nil, err.Error()
			}

			if err = s.userActiveTokenService.UpdateTokens(ctx, myUserDetails, ipAddress, deviceInfo, systemInfo); err != nil {
				return nil, err.Error()
			}
		}
	} else {
		// If the user input is not correct.
		// Actually we did not give info like 'user not found' or 'password is incorrect'.
		// This is for void brute force attack. If the attacker does NOT know the login id or password is correct that would make the attach more difficult.
		errorCode = errorcode.ErrorUserAccountBadCredential
	}

	return myUserDetails, errorCode
}

// AuthenticateByAuthCode authenticates user based on username and auth code.
func (s *authenticationService) AuthenticateByAuthCode(ctx context.Context, username string, authCode string) (myUserDetails *security.MyUserDetails, errorCode string) {
	myUserDetails = &security.MyUserDetails{}
	errorCode = ""

	return myUserDetails, errorCode
}

// RefreshToken refreshes access token with valid refresh token.
func (s *authenticationService) RefreshToken(ctx context.Context, refreshToken string, ipAddress string, deviceInfo *dto.UserDeviceInfoRequest, systemInfo *dto.UserSystemInfoRequest) (myUserDetails *security.MyUserDetails, errorCode string) {
	myUserDetails = &security.MyUserDetails{}
	var err error
	errorCode = ""

	claims, valid := security.ValidateToken(refreshToken, config.ConfigLoader.GetAppConfig().Jwt.SigningKey)

	// If token is valid we still need to check if it is still active.
	if valid {
		valid, err = s.userActiveTokenService.IsActiveRefreshToken(ctx, claims.Username, deviceInfo.ID, refreshToken)

		if err != nil {
			valid = false
		}
	}

	if valid {
		// Generates new details.
		if myUserDetails, err = s.generateUserDetails(ctx, claims.Username, deviceInfo.ID); err != nil {
			return nil, errorcode.ErrorServiceUnexpectedError
		}

		// But keeps refresh token unchanged.
		myUserDetails.RefreshToken = refreshToken

		// Updates new token as active.
		s.userActiveTokenService.UpdateTokens(ctx, myUserDetails, ipAddress, deviceInfo, systemInfo)
	} else {
		errorCode = errorcode.ErrorUserAccountNotAuthorized
	}

	return myUserDetails, errorCode
}

// SwitchAccountGroup authenticates user based on username and password.
func (s *authenticationService) SwitchAccountGroup(ctx context.Context, accountGroupID string, userID string, username string, ipAddress string, deviceInfo *dto.UserDeviceInfoRequest, systemInfo *dto.UserSystemInfoRequest) (myUserDetails *security.MyUserDetails, errorCode string) {
	myUserDetails = &security.MyUserDetails{}
	errorCode = ""
	exists, err := NewAccountGroupUserService(s.db).ExistByAccountGroupAndUser(ctx, accountGroupID, userID)

	if err != nil {
		errorCode = errorcode.ErrorUserAccountBadCredential
	}

	if exists {
		// Updates user current account group to provided one.
		if err := s.userService.UpdateAccountGroup(ctx, accountGroupID, userID); err == nil {
			// Generates MyUserDetails and token.
			if myUserDetails, err = s.generateUserDetails(ctx, username, deviceInfo.ID); err != nil {
				return nil, errorcode.ErrorServiceUnexpectedError
			}

			s.userActiveTokenService.UpdateTokens(ctx, myUserDetails, ipAddress, deviceInfo, systemInfo)
		}
	} else {
		// If the user input is not correct.
		// Actually we did not give info like 'user not found' or 'password is incorrect'.
		// This is for void brute force attack. If the attacker does NOT know the login id or password is correct that would make the attach more difficult.
		errorCode = errorcode.ErrorUserAccountBadCredential
	}

	return myUserDetails, errorCode
}

// Logout invalidates access token.
func (s *authenticationService) Logout(ctx context.Context, token string) {
	if !util.IsEmptyString(token) {
		s.userActiveTokenService.DeleteByAccessToken(ctx, token)
	}
}

// endregion

// region: Private members

// generateUserDetails generates MyUserDetails based on username.
func (s *authenticationService) generateUserDetails(ctx context.Context, username string, deviceID *string) (*security.MyUserDetails, error) {
	myUserDetails := &security.MyUserDetails{}

	if !util.IsEmptyString(username) {
		// Queries user details from db.
		userDetails, err := s.userService.QueryDetails(ctx, "", username)

		if err != nil {
			return nil, err
		}

		// Gets the configured signing key and expiration.
		appConfig := config.ConfigLoader.GetAppConfig()

		// For refresh token which does not need roles and it never expires.
		baseClaims := security.BaseClaims{
			UserIdentity: security.UserIdentity{
				UserBasicIdentity: security.UserBasicIdentity{
					UserID:   userDetails.ID,
					Username: userDetails.Username,
				},
				AccountGroupID: userDetails.AccountGroupID,
				DeviceID:       deviceID,
			},
		}

		// Generates refresh token. 0 means it never expires.
		refreshTokenString, err := security.GenerateToken(&baseClaims, appConfig.Jwt.SigningKey, 0)

		if err != nil {
			return nil, err
		}

		// Generates list of security.FullRole based on list of map item.
		fullRoles := make([]security.FullRole, 0, len(userDetails.FullRoles))

		// new() returns a pointer type.
		// fullRole := new(security.FullRole)

		// For claims usage.
		roleNames := make([]string, 0, len(userDetails.FullRoles))

		// Goes through list of map.
		for _, role := range userDetails.FullRoles {
			fullRoles = append(fullRoles, role)

			// Gets role name for claims.
			roleNames = append(roleNames, role.Authority)
		}

		// For access token which will expires in particular duration.
		claims := security.MyClaims{
			BaseClaims: baseClaims,
			Roles:      roleNames,
		}

		// Generates access token.
		accessTokenString, err := security.GenerateToken(&claims, appConfig.Jwt.SigningKey, appConfig.Jwt.Expiration)

		if err != nil {
			return nil, err
		}

		// Prepares MyUserDetails data.
		myUserDetails.AccountGroupID = userDetails.AccountGroupID
		myUserDetails.UserID = userDetails.ID
		myUserDetails.Username = userDetails.Username
		myUserDetails.FullName = userDetails.FullName
		myUserDetails.AvatarUrl = userDetails.AvatarUrl
		myUserDetails.IsAccountGroupCreator = *userDetails.IsAccountGroupCreator
		myUserDetails.Bearer = "bearer"
		myUserDetails.Roles = claims.Roles
		myUserDetails.FullRoles = fullRoles
		myUserDetails.AccessToken = accessTokenString
		myUserDetails.RefreshToken = refreshTokenString

		// claims.IssuedAt and claims.ExpiresAt are int64 type. That's why time.Unix() is used to parse it to time.Time type.
		myUserDetails.GeneratedAt = date.ConvertToDateString(claims.IssuedAt.Time)
		myUserDetails.ExpiredAt = date.ConvertToDateString(claims.ExpiresAt.Time)
		myUserDetails.ExpiresIn = appConfig.Jwt.Expiration
	}

	return myUserDetails, nil
}

// Container of AuthenticationService functions.
type authenticationService struct {
	db                     *sql.DB
	userService            UserService
	userActiveTokenService UserActiveTokenService
}

// endregion
